Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 0fb93442760ad4588506286da3815551e6bbd9bf


Parents : 0e3bfaf
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T10:03:40-05:00

feat: implement conversation summaries with improved database handling and API limits

Changes
Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 5559935c..f2b65378 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 442ea6bd..47af1b3d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -15219,6 +15219,7 @@ class ReticulumMeshChat:
limit = int(limit) if limit is not None else None
except ValueError:
limit = None
+ limit = self.message_handler.clamp_conversations_limit(limit)
try:
offset = request.query.get("offset")
@@ -15285,8 +15286,8 @@ class ReticulumMeshChat:
"background_colour": row["background_colour"],
}
- # contact image
- contact_image = row.get("contact_image", None)
+ # contact image blob stays out of the list payload
+ has_contact_image = bool(row.get("has_contact_image", 0))
try:
is_unread = compute_lxmf_conversation_unread_from_latest_row(
@@ -15315,7 +15316,8 @@ class ReticulumMeshChat:
{
"display_name": display_name,
"custom_display_name": row["custom_display_name"],
- "contact_image": contact_image,
+ "contact_image": None,
+ "has_contact_image": has_contact_image,
"destination_hash": other_user_hash,
"is_unread": is_unread,
"is_tracking": tracking_states.get(other_user_hash, False),

diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index a2800e73..35aa3e4a 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -249,6 +249,130 @@ class MessageDAO:
params.append(updated_at)
self.provider.execute(query, params)
+ peer_hash = data.get("peer_hash")
+ if isinstance(peer_hash, str) and peer_hash.strip():
+ self.refresh_conversation_summary(peer_hash.strip())
+
+ def refresh_conversation_summary(self, peer_hash):
+ """Rebuild the materialized list row for one peer.
+
+ Conversation list queries read lxmf_conversation_summaries so they do
+ not GROUP BY the full messages table on every refresh.
+ """
+ if not peer_hash or not isinstance(peer_hash, str):
+ return
+ peer_hash = peer_hash.strip()
+ if not peer_hash:
+ return
+ row = self.provider.fetchone(
+ """
+ SELECT
+ id, hash, source_hash, destination_hash, peer_hash, state, progress,
+ is_incoming, title,
+ substr(COALESCE(content, ''), 1, 240) AS content_preview,
+ timestamp, is_spam, reply_to_hash, created_at, updated_at,
+ COALESCE(has_image, 0) AS has_image,
+ COALESCE(has_audio, 0) AS has_audio,
+ COALESCE(has_files, 0) AS has_files,
+ COALESCE(has_reaction, 0) AS has_reaction,
+ COALESCE(has_telemetry, 0) AS has_telemetry
+ FROM lxmf_messages
+ WHERE peer_hash = ?
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (peer_hash,),
+ )
+ if not row:
+ self.provider.execute(
+ "DELETE FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer_hash,),
+ )
+ return
+ failed_row = self.provider.fetchone(
+ """
+ SELECT COUNT(*) AS failed_count
+ FROM lxmf_messages
+ WHERE peer_hash = ? AND state = 'failed'
+ """,
+ (peer_hash,),
+ )
+ failed_count = int(failed_row["failed_count"] or 0) if failed_row else 0
+ self.provider.execute(
+ """
+ INSERT INTO lxmf_conversation_summaries (
+ peer_hash, latest_message_id, latest_message_hash,
+ source_hash, destination_hash, state, progress, is_incoming,
+ title, content_preview, timestamp, is_spam, reply_to_hash,
+ created_at, updated_at,
+ has_image, has_audio, has_files, has_reaction, has_telemetry,
+ failed_count
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(peer_hash) DO UPDATE SET
+ latest_message_id = EXCLUDED.latest_message_id,
+ latest_message_hash = EXCLUDED.latest_message_hash,
+ source_hash = EXCLUDED.source_hash,
+ destination_hash = EXCLUDED.destination_hash,
+ state = EXCLUDED.state,
+ progress = EXCLUDED.progress,
+ is_incoming = EXCLUDED.is_incoming,
+ title = EXCLUDED.title,
+ content_preview = EXCLUDED.content_preview,
+ timestamp = EXCLUDED.timestamp,
+ is_spam = EXCLUDED.is_spam,
+ reply_to_hash = EXCLUDED.reply_to_hash,
+ created_at = EXCLUDED.created_at,
+ updated_at = EXCLUDED.updated_at,
+ has_image = EXCLUDED.has_image,
+ has_audio = EXCLUDED.has_audio,
+ has_files = EXCLUDED.has_files,
+ has_reaction = EXCLUDED.has_reaction,
+ has_telemetry = EXCLUDED.has_telemetry,
+ failed_count = EXCLUDED.failed_count
+ """,
+ (
+ peer_hash,
+ row["id"],
+ row["hash"],
+ row["source_hash"],
+ row["destination_hash"],
+ row["state"],
+ row["progress"],
+ row["is_incoming"],
+ row["title"],
+ row["content_preview"],
+ row["timestamp"],
+ row["is_spam"],
+ row["reply_to_hash"],
+ row["created_at"],
+ row["updated_at"],
+ row["has_image"],
+ row["has_audio"],
+ row["has_files"],
+ row["has_reaction"],
+ row["has_telemetry"],
+ failed_count,
+ ),
+ )
+
+ def refresh_conversation_summaries_for_peers(self, peer_hashes):
+ seen = set()
+ for peer_hash in peer_hashes or []:
+ if not isinstance(peer_hash, str):
+ continue
+ key = peer_hash.strip()
+ if not key or key in seen:
+ continue
+ seen.add(key)
+ self.refresh_conversation_summary(key)
+
+ def delete_conversation_summary(self, peer_hash):
+ if not peer_hash or not isinstance(peer_hash, str):
+ return
+ self.provider.execute(
+ "DELETE FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer_hash.strip(),),
+ )
def set_lxmf_message_path_at_send_if_unset(
self,
@@ -319,6 +443,12 @@ class MessageDAO:
message_hash,
),
)
+ row = self.provider.fetchone(
+ "SELECT peer_hash FROM lxmf_messages WHERE hash = ?",
+ (message_hash,),
+ )
+ if row and row.get("peer_hash"):
+ self.refresh_conversation_summary(row["peer_hash"])
def get_lxmf_message_by_hash(self, message_hash):
return self.provider.fetchone(
@@ -406,21 +536,35 @@ class MessageDAO:
if not message_hashes:
return
placeholders = ", ".join(["?"] * len(message_hashes))
+ peers = self.provider.fetchall(
+ f"SELECT DISTINCT peer_hash FROM lxmf_messages WHERE hash IN ({placeholders})",
+ tuple(message_hashes),
+ )
self.provider.execute(
f"DELETE FROM lxmf_messages WHERE hash IN ({placeholders})",
tuple(message_hashes),
)
+ self.refresh_conversation_summaries_for_peers(
+ [row["peer_hash"] for row in peers if row and row.get("peer_hash")],
+ )
def delete_lxmf_message_by_hash(self, message_hash):
+ row = self.provider.fetchone(
+ "SELECT peer_hash FROM lxmf_messages WHERE hash = ?",
+ (message_hash,),
+ )
self.provider.execute(
"DELETE FROM lxmf_messages WHERE hash = ?",
(message_hash,),
)
+ if row and row.get("peer_hash"):
+ self.refresh_conversation_summary(row["peer_hash"])
def delete_all_lxmf_messages(self):
with self.provider:
self.provider.execute("DELETE FROM lxmf_messages")
self.provider.execute("DELETE FROM lxmf_conversation_read_state")
+ self.provider.execute("DELETE FROM lxmf_conversation_summaries")
def get_all_lxmf_messages(self, limit=5000, offset=0):
return self.provider.fetchall(
@@ -517,14 +661,10 @@ class MessageDAO:
def get_conversations(self):
query = f"""
- SELECT {self.CONVERSATION_LIST_COLUMNS} FROM lxmf_messages m1
- INNER JOIN (
- SELECT peer_hash, MAX(id) as max_id
- FROM lxmf_messages
- WHERE peer_hash IS NOT NULL
- GROUP BY peer_hash
- ) m2 ON m1.peer_hash = m2.peer_hash AND m1.id = m2.max_id
- ORDER BY m1.id DESC
+ SELECT {self.CONVERSATION_LIST_COLUMNS}
+ FROM lxmf_conversation_summaries s
+ INNER JOIN lxmf_messages m1 ON m1.id = s.latest_message_id
+ ORDER BY s.latest_message_id DESC
"""
return self.provider.fetchall(query)

diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index eb2287df..d267bd51 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -19,7 +19,7 @@ def _validate_identifier(name: str, label: str = "identifier") -> str:
class DatabaseSchema:
- LATEST_VERSION = 51
+ LATEST_VERSION = 52
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -1517,3 +1517,93 @@ class DatabaseSchema:
AND fields IS NOT NULL AND length(fields) > 16384
""",
)
+
+ if current_version < 52:
+ # Materialized per-peer latest row so conversation list queries do not
+ # GROUP BY the full lxmf_messages table on every refresh.
+ self._safe_execute(
+ """
+ CREATE TABLE IF NOT EXISTS lxmf_conversation_summaries (
+ peer_hash TEXT PRIMARY KEY NOT NULL,
+ latest_message_id INTEGER NOT NULL,
+ latest_message_hash TEXT,
+ source_hash TEXT,
+ destination_hash TEXT,
+ state TEXT,
+ progress REAL,
+ is_incoming INTEGER,
+ title TEXT,
+ content_preview TEXT,
+ timestamp REAL,
+ is_spam INTEGER,
+ reply_to_hash TEXT,
+ created_at TEXT,
+ updated_at TEXT,
+ has_image INTEGER,
+ has_audio INTEGER,
+ has_files INTEGER,
+ has_reaction INTEGER,
+ has_telemetry INTEGER,
+ failed_count INTEGER NOT NULL DEFAULT 0
+ )
+ """,
+ )
+ self._safe_execute(
+ "CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_summaries_latest_id "
+ "ON lxmf_conversation_summaries(latest_message_id DESC)",
+ )
+ self._safe_execute(
+ "CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_summaries_timestamp "
+ "ON lxmf_conversation_summaries(timestamp DESC)",
+ )
+ has_messages = self.provider.fetchone(
+ "SELECT 1 AS ok FROM lxmf_messages LIMIT 1",
+ )
+ if has_messages:
+ self._safe_execute(
+ """
+ INSERT OR REPLACE INTO lxmf_conversation_summaries (
+ peer_hash, latest_message_id, latest_message_hash,
+ source_hash, destination_hash, state, progress, is_incoming,
+ title, content_preview, timestamp, is_spam, reply_to_hash,
+ created_at, updated_at,
+ has_image, has_audio, has_files, has_reaction, has_telemetry,
+ failed_count
+ )
+ SELECT
+ m.peer_hash,
+ m.id,
+ m.hash,
+ m.source_hash,
+ m.destination_hash,
+ m.state,
+ m.progress,
+ m.is_incoming,
+ m.title,
+ substr(COALESCE(m.content, ''), 1, 240),
+ m.timestamp,
+ m.is_spam,
+ m.reply_to_hash,
+ m.created_at,
+ m.updated_at,
+ COALESCE(m.has_image, 0),
+ COALESCE(m.has_audio, 0),
+ COALESCE(m.has_files, 0),
+ COALESCE(m.has_reaction, 0),
+ COALESCE(m.has_telemetry, 0),
+ COALESCE(fc.failed_count, 0)
+ FROM lxmf_messages m
+ INNER JOIN (
+ SELECT peer_hash, MAX(id) AS max_id
+ FROM lxmf_messages
+ WHERE peer_hash IS NOT NULL
+ GROUP BY peer_hash
+ ) latest ON latest.peer_hash = m.peer_hash AND latest.max_id = m.id
+ LEFT JOIN (
+ SELECT peer_hash, COUNT(*) AS failed_count
+ FROM lxmf_messages
+ WHERE state = 'failed'
+ GROUP BY peer_hash
+ ) fc ON fc.peer_hash = m.peer_hash
+ """,
+ )

diff --git a/meshchatx/src/backend/message_handler.py b/meshchatx/src/backend/message_handler.py
index 2e13e98f..c527daa5 100644
--- a/meshchatx/src/backend/message_handler.py
+++ b/meshchatx/src/backend/message_handler.py
@@ -33,6 +33,10 @@ class MessageHandler:
END as fields
"""
+ # Default and hard cap when callers omit or overshoot limit.
+ DEFAULT_CONVERSATIONS_LIMIT = 500
+ MAX_CONVERSATIONS_LIMIT = 2000
+
def get_conversation_messages(
self,
local_hash,
@@ -77,6 +81,7 @@ class MessageHandler:
"DELETE FROM lxmf_conversation_pins WHERE peer_hash = ?",
[destination_hash],
)
+ self.db.messages.delete_conversation_summary(destination_hash)
def search_messages(self, local_hash, search_term, limit=500):
search_term = _strip_utf16_surrogates(search_term) or ""
@@ -97,27 +102,36 @@ class MessageHandler:
# Prefer persisted has_* columns (schema v51+). Fall back to instr only
# when flags were never backfilled (NULL) on filter paths.
_CONVERSATION_CONTENT_PREVIEW_CHARS = 240
- _FIELDS_HAS_IMAGE_SQL = "COALESCE(m1.has_image, 0)"
- _FIELDS_HAS_AUDIO_SQL = "COALESCE(m1.has_audio, 0)"
- _FIELDS_HAS_FILES_SQL = "COALESCE(m1.has_files, 0)"
- _FIELDS_HAS_REACTION_SQL = "COALESCE(m1.has_reaction, 0)"
- _FIELDS_HAS_TELEMETRY_SQL = "COALESCE(m1.has_telemetry, 0)"
+ _FIELDS_HAS_IMAGE_SQL = "COALESCE(s.has_image, 0)"
+ _FIELDS_HAS_AUDIO_SQL = "COALESCE(s.has_audio, 0)"
+ _FIELDS_HAS_FILES_SQL = "COALESCE(s.has_files, 0)"
+ _FIELDS_HAS_REACTION_SQL = "COALESCE(s.has_reaction, 0)"
+ _FIELDS_HAS_TELEMETRY_SQL = "COALESCE(s.has_telemetry, 0)"
_FIELDS_HAS_ATTACHMENTS_SQL = (
f"({_FIELDS_HAS_IMAGE_SQL} = 1 OR {_FIELDS_HAS_AUDIO_SQL} = 1 "
f"OR {_FIELDS_HAS_FILES_SQL} = 1)"
)
- # Filter path: prefer columns so filter_has_attachments does not scan blobs.
+ # Filter path: prefer summary has_* so filter_has_attachments stays cheap.
_FILTER_HAS_ATTACHMENTS_SQL = (
- "(COALESCE(m1.has_image, 0) = 1 OR COALESCE(m1.has_audio, 0) = 1 "
- "OR COALESCE(m1.has_files, 0) = 1 OR "
- "(m1.has_image IS NULL AND m1.has_audio IS NULL AND m1.has_files IS NULL "
- "AND m1.fields IS NOT NULL AND m1.fields != '' AND m1.fields != '{}' "
- "AND (instr(m1.fields, '\"image\"') > 0 OR instr(m1.fields, '\"0x05\"') > 0 "
- "OR instr(m1.fields, '\"audio\"') > 0 OR instr(m1.fields, '\"0x06\"') > 0 "
- "OR instr(m1.fields, '\"file_attachments\"') > 0 "
- "OR instr(m1.fields, '\"0x07\"') > 0)))"
+ "(COALESCE(s.has_image, 0) = 1 OR COALESCE(s.has_audio, 0) = 1 "
+ "OR COALESCE(s.has_files, 0) = 1)"
)
+ @classmethod
+ def clamp_conversations_limit(cls, limit):
+ """Normalize list limit. None becomes the default. Cap at MAX."""
+ if limit is None:
+ return cls.DEFAULT_CONVERSATIONS_LIMIT
+ try:
+ value = int(limit)
+ except (TypeError, ValueError):
+ return cls.DEFAULT_CONVERSATIONS_LIMIT
+ if value < 0:
+ return 0
+ if value > cls.MAX_CONVERSATIONS_LIMIT:
+ return cls.MAX_CONVERSATIONS_LIMIT
+ return value
+
def get_conversations(
self,
local_hash,
@@ -129,16 +143,23 @@ class MessageHandler:
limit=500,
offset=0,
):
- preview_chars = self._CONVERSATION_CONTENT_PREVIEW_CHARS
+ limit = self.clamp_conversations_limit(limit)
+ try:
+ offset = max(0, int(offset or 0))
+ except (TypeError, ValueError):
+ offset = 0
+
query = f"""
SELECT
- m1.id, m1.hash, m1.source_hash, m1.destination_hash,
- m1.peer_hash, m1.state, m1.progress, m1.is_incoming,
- m1.title,
- substr(COALESCE(m1.content, ''), 1, {preview_chars}) as content,
- m1.timestamp,
- m1.is_spam, m1.reply_to_hash,
- m1.created_at, m1.updated_at,
+ s.latest_message_id as id,
+ s.latest_message_hash as hash,
+ s.source_hash, s.destination_hash,
+ s.peer_hash, s.state, s.progress, s.is_incoming,
+ s.title,
+ s.content_preview as content,
+ s.timestamp,
+ s.is_spam, s.reply_to_hash,
+ s.created_at, s.updated_at,
({self._FIELDS_HAS_IMAGE_SQL}) as has_image,
({self._FIELDS_HAS_AUDIO_SQL}) as has_audio,
({self._FIELDS_HAS_FILES_SQL}) as has_files,
@@ -147,37 +168,28 @@ class MessageHandler:
CASE WHEN {self._FIELDS_HAS_ATTACHMENTS_SQL} THEN 1 ELSE 0 END as has_attachments,
a.app_data as peer_app_data,
c.display_name as custom_display_name,
- con.custom_image as contact_image,
+ CASE
+ WHEN con.custom_image IS NOT NULL AND con.custom_image != ''
+ THEN 1 ELSE 0
+ END as has_contact_image,
con.name as contact_name,
i.icon_name, i.foreground_colour, i.background_colour,
r.last_read_at,
f.id as folder_id,
fn.name as folder_name,
- COALESCE(fc.failed_count, 0) as failed_count,
+ COALESCE(s.failed_count, 0) as failed_count,
CASE WHEN con.id IS NOT NULL THEN 1 ELSE 0 END as is_contact
- FROM lxmf_messages m1
- INNER JOIN (
- SELECT peer_hash, MAX(id) as max_id
- FROM lxmf_messages
- WHERE peer_hash IS NOT NULL
- GROUP BY peer_hash
- ) m2 ON m1.peer_hash = m2.peer_hash AND m1.id = m2.max_id
- LEFT JOIN (
- SELECT peer_hash, COUNT(*) as failed_count
- FROM lxmf_messages
- WHERE state = 'failed'
- GROUP BY peer_hash
- ) fc ON fc.peer_hash = m1.peer_hash
- LEFT JOIN announces a ON a.destination_hash = m1.peer_hash
- LEFT JOIN custom_destination_display_names c ON c.destination_hash = m1.peer_hash
+ FROM lxmf_conversation_summaries s
+ LEFT JOIN announces a ON a.destination_hash = s.peer_hash
+ LEFT JOIN custom_destination_display_names c ON c.destination_hash = s.peer_hash
LEFT JOIN contacts con ON (
- con.remote_identity_hash = m1.peer_hash OR
- con.lxmf_address = m1.peer_hash OR
- con.lxst_address = m1.peer_hash
+ con.remote_identity_hash = s.peer_hash OR
+ con.lxmf_address = s.peer_hash OR
+ con.lxst_address = s.peer_hash
)
- LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
- LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
- LEFT JOIN lxmf_conversation_folders f ON f.peer_hash = m1.peer_hash
+ LEFT JOIN lxmf_user_icons i ON i.destination_hash = s.peer_hash
+ LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = s.peer_hash
+ LEFT JOIN lxmf_conversation_folders f ON f.peer_hash = s.peer_hash
LEFT JOIN lxmf_folders fn ON fn.id = f.folder_id
"""
params = []
@@ -193,11 +205,11 @@ class MessageHandler:
if filter_unread:
where_clauses.append(
- "(m1.is_incoming = 1 AND (r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at)))",
+ "(s.is_incoming = 1 AND (r.last_read_at IS NULL OR s.timestamp > strftime('%s', r.last_read_at)))",
)
if filter_failed:
- where_clauses.append("m1.state = 'failed'")
+ where_clauses.append("s.state = 'failed'")
if filter_has_attachments:
where_clauses.append(self._FILTER_HAS_ATTACHMENTS_SQL)
@@ -206,10 +218,14 @@ class MessageHandler:
search = _strip_utf16_surrogates(search) or ""
if search:
like_term = f"%{search}%"
- # Search in latest message info OR search across ALL messages for this peer
+ # Search latest summary fields or any historical message for the peer
where_clauses.append("""
- (m1.title LIKE ? OR m1.content LIKE ? OR m1.peer_hash LIKE ? OR c.display_name LIKE ? OR con.name LIKE ?
- OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
+ (s.title LIKE ? OR s.content_preview LIKE ? OR s.peer_hash LIKE ?
+ OR c.display_name LIKE ? OR con.name LIKE ?
+ OR s.peer_hash IN (
+ SELECT peer_hash FROM lxmf_messages
+ WHERE title LIKE ? OR content LIKE ?
+ ))
""")
params.extend(
[
@@ -226,10 +242,8 @@ class MessageHandler:
if where_clauses:
query += " WHERE " + " AND ".join(where_clauses)
- query += " GROUP BY m1.peer_hash ORDER BY m1.id DESC"
-
- if limit is not None:
- query += " LIMIT ? OFFSET ?"
- params.extend([limit, offset])
+ query += " GROUP BY s.peer_hash ORDER BY s.latest_message_id DESC"
+ query += " LIMIT ? OFFSET ?"
+ params.extend([limit, offset])
return self.db.provider.fetchall(query, params)

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index 9392e89c..00e4daeb 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -3398,7 +3398,9 @@ export default {
async fetchPeers() {
if (!window.api) return;
try {
- const response = await window.api.get("/api/v1/lxmf/conversations");
+ const response = await window.api.get("/api/v1/lxmf/conversations", {
+ params: { limit: 2000 },
+ });
const peers = {};
for (const conv of response.data.conversations) {
peers[conv.destination_hash] = conv;

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 1e9082a8..c47ba726 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -540,6 +540,9 @@ export default {
clearInterval(this.reloadInterval);
clearTimeout(this.conversationRefreshTimeout);
clearTimeout(this.peersRefreshTimeout);
+ if (this._onConversationsVisibility && typeof document !== "undefined") {
+ document.removeEventListener("visibilitychange", this._onConversationsVisibility);
+ }
this.conversationsAbortController?.abort();
this.announcesAbortController?.abort();
this.stopIngestScanner();
@@ -566,11 +569,23 @@ export default {
this.loadConversationPins();
this.getFolders();
- // update info every few seconds
+ // Poll while visible. WS refresh-conversations covers most live updates.
this.reloadInterval = setInterval(() => {
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
+ return;
+ }
this.getConversations();
this.getFolders();
- }, 5000);
+ }, 15000);
+
+ this._onConversationsVisibility = () => {
+ if (typeof document !== "undefined" && document.visibilityState === "visible") {
+ this.requestConversationsRefresh();
+ }
+ };
+ if (typeof document !== "undefined") {
+ document.addEventListener("visibilitychange", this._onConversationsVisibility);
+ }
// compose message if a destination hash was provided on page load
if (this.destinationHash) {

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 958cc4f8..01486cc1 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -674,6 +674,7 @@ export default {
try {
const response = await window.api.get(`/api/v1/lxmf/conversations`, {
signal: this.abortController.signal,
+ params: { limit: 2000 },
});
this.conversations = {};
for (const conversation of response.data.conversations) {

diff --git a/tests/backend/test_conversation_load_fast.py b/tests/backend/test_conversation_load_fast.py
index b3e34e62..8562cdb4 100644
--- a/tests/backend/test_conversation_load_fast.py
+++ b/tests/backend/test_conversation_load_fast.py
@@ -133,6 +133,12 @@ class TestConversationLoadFast(unittest.TestCase):
self.assertEqual(row["has_image"], 1)
self.assertEqual(row["has_attachments"], 1)
self.assertNotIn("fields", row.keys())
+ self.assertIn("has_contact_image", row.keys())
+ summary = self.db.provider.fetchone(
+ "SELECT has_image FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertEqual(summary["has_image"], 1)
if __name__ == "__main__":

diff --git a/tests/backend/test_conversation_summaries.py b/tests/backend/test_conversation_summaries.py
new file mode 100644
index 00000000..33870055
--- /dev/null
+++ b/tests/backend/test_conversation_summaries.py
@@ -0,0 +1,221 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Unit and integration tests for lxmf_conversation_summaries."""
+
+from __future__ import annotations
+
+import secrets
+import tempfile
+import time
+import unittest
+
+from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.database.provider import DatabaseProvider
+from meshchatx.src.backend.message_handler import MessageHandler
+
+
+def _message(
+ peer_hash, i, *, state="delivered", content="hello", title=None, is_incoming=1
+):
+ return {
+ "hash": secrets.token_hex(16),
+ "source_hash": peer_hash if is_incoming else "localhashlocalhashlocalhashlo12",
+ "destination_hash": "localhashlocalhashlocalhashlo12"
+ if is_incoming
+ else peer_hash,
+ "peer_hash": peer_hash,
+ "state": state,
+ "progress": 1.0,
+ "is_incoming": is_incoming,
+ "method": "direct",
+ "delivery_attempts": 1,
+ "next_delivery_attempt_at": None,
+ "title": title if title is not None else f"t{i}",
+ "content": content,
+ "fields": "{}",
+ "timestamp": time.time() - i,
+ "rssi": -50,
+ "snr": 5.0,
+ "quality": 3,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ }
+
+
+class TestConversationSummaries(unittest.TestCase):
+ def setUp(self):
+ if DatabaseProvider._instance is not None:
+ DatabaseProvider._instance.close_all()
+ DatabaseProvider._instance = None
+ self.tmp = tempfile.TemporaryDirectory()
+ self.db = Database(f"{self.tmp.name}/database.db")
+ self.db.initialize()
+ self.handler = MessageHandler(self.db)
+
+ def tearDown(self):
+ self.db.close()
+ if DatabaseProvider._instance is not None:
+ DatabaseProvider._instance.close_all()
+ DatabaseProvider._instance = None
+ self.tmp.cleanup()
+
+ def test_schema_creates_summaries_table(self):
+ row = self.db.provider.fetchone(
+ "SELECT name FROM sqlite_master WHERE type='table' "
+ "AND name='lxmf_conversation_summaries'",
+ )
+ self.assertIsNotNone(row)
+
+ def test_upsert_creates_and_updates_summary(self):
+ peer = secrets.token_hex(16)
+ self.db.messages.upsert_lxmf_message(_message(peer, 2, content="old"))
+ self.db.messages.upsert_lxmf_message(_message(peer, 1, content="new"))
+ summary = self.db.provider.fetchone(
+ "SELECT content_preview, latest_message_id FROM lxmf_conversation_summaries "
+ "WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertEqual(summary["content_preview"], "new")
+ latest = self.db.provider.fetchone(
+ "SELECT MAX(id) AS max_id FROM lxmf_messages WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertEqual(summary["latest_message_id"], latest["max_id"])
+
+ def test_failed_state_updates_failed_count(self):
+ peer = secrets.token_hex(16)
+ first = _message(peer, 2, state="failed")
+ self.db.messages.upsert_lxmf_message(first)
+ second = _message(peer, 1, state="delivered")
+ self.db.messages.upsert_lxmf_message(second)
+ summary = self.db.provider.fetchone(
+ "SELECT failed_count, state FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertEqual(summary["failed_count"], 1)
+ self.assertEqual(summary["state"], "delivered")
+
+ self.db.messages.update_lxmf_message_state(
+ second["hash"],
+ "failed",
+ 0.0,
+ 2,
+ None,
+ )
+ summary = self.db.provider.fetchone(
+ "SELECT failed_count, state FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertEqual(summary["failed_count"], 2)
+ self.assertEqual(summary["state"], "failed")
+
+ def test_delete_last_message_removes_summary(self):
+ peer = secrets.token_hex(16)
+ msg = _message(peer, 0)
+ self.db.messages.upsert_lxmf_message(msg)
+ self.db.messages.delete_lxmf_message_by_hash(msg["hash"])
+ summary = self.db.provider.fetchone(
+ "SELECT 1 AS ok FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertIsNone(summary)
+
+ def test_delete_one_of_many_keeps_previous_latest(self):
+ peer = secrets.token_hex(16)
+ older = _message(peer, 2, content="older")
+ newer = _message(peer, 1, content="newer")
+ self.db.messages.upsert_lxmf_message(older)
+ self.db.messages.upsert_lxmf_message(newer)
+ self.db.messages.delete_lxmf_message_by_hash(newer["hash"])
+ summary = self.db.provider.fetchone(
+ "SELECT content_preview FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertEqual(summary["content_preview"], "older")
+
+ def test_delete_conversation_removes_summary(self):
+ peer = secrets.token_hex(16)
+ self.db.messages.upsert_lxmf_message(_message(peer, 0))
+ self.handler.delete_conversation("local", peer)
+ summary = self.db.provider.fetchone(
+ "SELECT 1 AS ok FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertIsNone(summary)
+
+ def test_rebuild_after_empty_summary_table(self):
+ peer = secrets.token_hex(16)
+ self.db.messages.upsert_lxmf_message(_message(peer, 0, content="kept"))
+ self.db.provider.execute("DELETE FROM lxmf_conversation_summaries")
+ self.db.messages.refresh_conversation_summary(peer)
+ summary = self.db.provider.fetchone(
+ "SELECT content_preview FROM lxmf_conversation_summaries WHERE peer_hash = ?",
+ (peer,),
+ )
+ self.assertEqual(summary["content_preview"], "kept")
+
+ def test_get_conversations_reads_summaries_without_fields(self):
+ peer_a = secrets.token_hex(16)
+ peer_b = secrets.token_hex(16)
+ self.db.messages.upsert_lxmf_message(_message(peer_a, 1, content="alpha"))
+ self.db.messages.upsert_lxmf_message(_message(peer_b, 0, content="beta"))
+ rows = self.handler.get_conversations("local", limit=10)
+ self.assertEqual(len(rows), 2)
+ for row in rows:
+ row = dict(row)
+ self.assertNotIn("fields", row)
+ self.assertNotIn("contact_image", row)
+ self.assertIn("has_contact_image", row)
+ self.assertLessEqual(len(row.get("content") or ""), 240)
+
+ def test_pagination_order_stable(self):
+ peers = [secrets.token_hex(16) for _ in range(5)]
+ for i, peer in enumerate(peers):
+ self.db.messages.upsert_lxmf_message(
+ _message(peer, 10 - i, content=f"p{i}")
+ )
+ page1 = [
+ dict(r)["peer_hash"]
+ for r in self.handler.get_conversations("local", limit=2, offset=0)
+ ]
+ page2 = [
+ dict(r)["peer_hash"]
+ for r in self.handler.get_conversations("local", limit=2, offset=2)
+ ]
+ self.assertEqual(len(page1), 2)
+ self.assertEqual(len(page2), 2)
+ self.assertEqual(len(set(page1) & set(page2)), 0)
+
+ def test_search_matches_older_message(self):
+ peer = secrets.token_hex(16)
+ self.db.messages.upsert_lxmf_message(
+ _message(peer, 2, content="needle in a haystack", title="old"),
+ )
+ self.db.messages.upsert_lxmf_message(
+ _message(peer, 1, content="latest only", title="new"),
+ )
+ rows = self.handler.get_conversations("local", search="needle")
+ hashes = {dict(r)["peer_hash"] for r in rows}
+ self.assertIn(peer, hashes)
+
+ def test_filter_failed_and_unread(self):
+ failed_peer = secrets.token_hex(16)
+ unread_peer = secrets.token_hex(16)
+ self.db.messages.upsert_lxmf_message(
+ _message(failed_peer, 0, state="failed", content="boom"),
+ )
+ self.db.messages.upsert_lxmf_message(
+ _message(unread_peer, 0, content="hi", is_incoming=1),
+ )
+ failed_rows = self.handler.get_conversations("local", filter_failed=True)
+ self.assertEqual({dict(r)["peer_hash"] for r in failed_rows}, {failed_peer})
+ unread_rows = self.handler.get_conversations("local", filter_unread=True)
+ self.assertIn(unread_peer, {dict(r)["peer_hash"] for r in unread_rows})
+
+ def test_omit_limit_clamped_in_handler(self):
+ peer = secrets.token_hex(16)
+ self.db.messages.upsert_lxmf_message(_message(peer, 0))
+ rows = self.handler.get_conversations("local", limit=None)
+ self.assertEqual(len(rows), 1)
+ rows_capped = self.handler.get_conversations("local", limit=99999)
+ self.assertEqual(len(rows_capped), 1)

diff --git a/tests/backend/test_conversation_summaries_landlock.py b/tests/backend/test_conversation_summaries_landlock.py
new file mode 100644
index 00000000..25a181e5
--- /dev/null
+++ b/tests/backend/test_conversation_summaries_landlock.py
@@ -0,0 +1,113 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Landlock coverage for conversation summary list queries."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from meshchatx.src.backend.landlock_sandbox import landlock_kernel_supported
+
+
+@pytest.mark.skipif(
+ not landlock_kernel_supported(),
+ reason="Landlock not available on this kernel",
+)
+def test_landlock_conversation_summaries_list_query_ok():
+ """List-shaped summary queries must work under Landlock MEMORY temp."""
+ script = textwrap.dedent(
+ r"""
+ import os
+ import secrets
+ import sys
+ import tempfile
+ import time
+
+ from meshchatx.src.backend.database import Database
+ from meshchatx.src.backend.database.provider import DatabaseProvider
+ from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox
+ from meshchatx.src.backend.message_handler import MessageHandler
+
+ td = tempfile.mkdtemp(prefix="ll_conv_sum_")
+ storage = os.path.join(td, "storage")
+ os.makedirs(storage)
+ os.environ["MESHCHAT_LANDLOCK"] = "1"
+ db_path = os.path.join(storage, "database.db")
+
+ if DatabaseProvider._instance is not None:
+ DatabaseProvider._instance.close_all()
+ DatabaseProvider._instance = None
+ db = Database(db_path)
+ db.initialize()
+ handler = MessageHandler(db)
+
+ for i in range(80):
+ peer = secrets.token_hex(16)
+ db.messages.upsert_lxmf_message(
+ {
+ "hash": secrets.token_hex(16),
+ "source_hash": peer,
+ "destination_hash": "localhashlocalhashlocalhashlo12",
+ "peer_hash": peer,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": 1,
+ "method": "direct",
+ "delivery_attempts": 1,
+ "next_delivery_attempt_at": None,
+ "title": f"t{i}",
+ "content": "hello " + ("x" * 400),
+ "fields": "{}",
+ "timestamp": time.time() - i,
+ "rssi": -50,
+ "snr": 5.0,
+ "quality": 3,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ }
+ )
+
+ ok = apply_landlock_sandbox(
+ storage_dir=storage,
+ reticulum_config_dir=storage,
+ log_dir=storage,
+ )
+ if not ok:
+ print("LANDLOCK_NOT_APPLIED")
+ sys.exit(2)
+
+ rows = handler.get_conversations("local", limit=50, offset=0)
+ if len(rows) != 50:
+ print("BAD_COUNT", len(rows))
+ sys.exit(3)
+ for row in rows:
+ row = dict(row)
+ if "fields" in row:
+ print("FIELDS_PRESENT")
+ sys.exit(4)
+ if len(row.get("content") or "") > 240:
+ print("PREVIEW_TOO_LONG")
+ sys.exit(5)
+ print("OK")
+ sys.exit(0)
+ """,
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=str(Path(__file__).resolve().parents[2]),
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+ if "LANDLOCK_NOT_APPLIED" in result.stdout:
+ pytest.skip("Landlock could not be applied in this environment")
+ assert result.returncode == 0, (
+ f"stdout={result.stdout!r} stderr={result.stderr!r} code={result.returncode}"
+ )
+ assert "OK" in result.stdout

diff --git a/tests/backend/test_message_handler.py b/tests/backend/test_message_handler.py
index 50752362..b1f017f9 100644
--- a/tests/backend/test_message_handler.py
+++ b/tests/backend/test_message_handler.py
@@ -38,6 +38,7 @@ class TestMessageHandler(unittest.TestCase):
self.assertEqual(p2, ["dest"])
self.assertIn("DELETE FROM lxmf_conversation_pins", sql3)
self.assertEqual(p3, ["dest"])
+ self.db.messages.delete_conversation_summary.assert_called_once_with("dest")
def test_get_conversations_includes_failed_count(self):
self.db.provider.fetchall.return_value = [
@@ -60,7 +61,7 @@ class TestMessageHandler(unittest.TestCase):
"updated_at": "2023-01-01",
"peer_app_data": None,
"custom_display_name": None,
- "contact_image": None,
+ "has_contact_image": 0,
"icon_name": None,
"foreground_colour": None,
"background_colour": None,
@@ -79,7 +80,7 @@ class TestMessageHandler(unittest.TestCase):
self.db.provider.fetchall.return_value = []
self.handler.get_conversations("local", filter_failed=True)
args, _ = self.db.provider.fetchall.call_args
- self.assertIn("state = 'failed'", args[0])
+ self.assertIn("s.state = 'failed'", args[0])
def test_search_messages(self):
self.db.provider.fetchall.return_value = [

diff --git a/tests/backend/test_message_handler_extended.py b/tests/backend/test_message_handler_extended.py
index c4c581ab..9c680809 100644
--- a/tests/backend/test_message_handler_extended.py
+++ b/tests/backend/test_message_handler_extended.py
@@ -70,14 +70,17 @@ def test_get_conversations_base(mock_db):
args, _ = mock_db.provider.fetchall.call_args
query = args[0]
assert "SELECT" in query
- assert "FROM lxmf_messages m1" in query
- assert "substr(COALESCE(m1.content, ''), 1," in query
+ assert "FROM lxmf_conversation_summaries s" in query
+ assert "content_preview" in query
assert "has_image" in query
assert "has_attachments" in query
assert "failed_count" in query
- # Full attachment blobs must never be selected into the list API.
+ assert "has_contact_image" in query
+ # Full attachment blobs and contact images must never be selected into the list API.
assert ", m1.fields," not in query
- assert ", m1.content," not in query
+ assert "con.custom_image as contact_image" not in query
+ assert "SELECT peer_hash, MAX(id) as max_id" not in query
+ assert "FROM lxmf_messages\n WHERE state = 'failed'" not in query
def test_get_conversations_with_filters(mock_db):
@@ -94,7 +97,17 @@ def test_get_conversations_with_filters(mock_db):
query = args[0]
params = args[1]
# Check if any part of the query matches search or filters
- assert "m1.peer_hash" in query
- assert "m1.state = 'failed'" in query
- assert "COALESCE(m1.has_image" in query or "has_image" in query
+ assert "s.peer_hash" in query
+ assert "s.state = 'failed'" in query
+ assert "COALESCE(s.has_image" in query or "has_image" in query
assert "%test%" in params
+
+
+def test_clamp_conversations_limit():
+ from meshchatx.src.backend.message_handler import MessageHandler
+
+ assert MessageHandler.clamp_conversations_limit(None) == 500
+ assert MessageHandler.clamp_conversations_limit(50) == 50
+ assert MessageHandler.clamp_conversations_limit(99999) == 2000
+ assert MessageHandler.clamp_conversations_limit(-1) == 0
+ assert MessageHandler.clamp_conversations_limit("nope") == 500

diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index 22d4f6d7..b9b95c73 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -79,6 +79,68 @@ describe("MessagesPage.vue", () => {
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object));
});
+ it("skips periodic conversation poll while the document is hidden", async () => {
+ vi.useFakeTimers();
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+ axiosMock.get.mockClear();
+
+ const previousVisibility = Object.getOwnPropertyDescriptor(document, "visibilityState");
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ get: () => "hidden",
+ });
+
+ await vi.advanceTimersByTimeAsync(15000);
+ const hiddenCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations");
+ expect(hiddenCalls).toHaveLength(0);
+
+ if (previousVisibility) {
+ Object.defineProperty(document, "visibilityState", previousVisibility);
+ } else {
+ delete document.visibilityState;
+ }
+ vi.useRealTimers();
+ wrapper.unmount();
+ });
+
+ it("keeps conversations usable when contact_image is omitted from list payload", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversations") {
+ return Promise.resolve({
+ data: {
+ conversations: [
+ {
+ destination_hash: "aabbccddeeff00112233445566778899",
+ display_name: "Peer",
+ contact_image: null,
+ has_contact_image: true,
+ is_unread: false,
+ failed_messages_count: 0,
+ has_attachments: false,
+ latest_message_preview: "hi",
+ latest_message_created_at: new Date().toISOString(),
+ is_contact: true,
+ },
+ ],
+ },
+ });
+ }
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
+ if (url === "/api/v1/lxmf/folders") return Promise.resolve({ data: [] });
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountMessagesPage();
+ await flushPromises();
+ expect(wrapper.vm.conversations.length).toBeGreaterThanOrEqual(1);
+ expect(wrapper.vm.conversations[0].contact_image).toBeNull();
+ expect(wrapper.vm.conversations[0].has_contact_image).toBe(true);
+ wrapper.unmount();
+ });
+
it("does not fetch lxmf delivery announces until the announces tab is opened", async () => {
mountMessagesPage();
await flushPromises();

diff --git a/tests/frontend/conversationListApiCaps.test.js b/tests/frontend/conversationListApiCaps.test.js
new file mode 100644
index 00000000..c95a0716
--- /dev/null
+++ b/tests/frontend/conversationListApiCaps.test.js
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Map and NetworkVisualiser must pass an explicit conversations limit.
+ */
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const ROOT = resolve(process.cwd());
+
+describe("conversation list API caps", () => {
+ it("NetworkVisualiser requests conversations with an explicit limit", () => {
+ const src = readFileSync(
+ resolve(ROOT, "meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue"),
+ "utf8"
+ );
+ expect(src).toMatch(/\/api\/v1\/lxmf\/conversations/);
+ expect(src).toMatch(/params:\s*\{\s*limit:\s*2000\s*\}/);
+ });
+
+ it("MapPage requests conversations with an explicit limit", () => {
+ const src = readFileSync(resolve(ROOT, "meshchatx/src/frontend/components/map/MapPage.vue"), "utf8");
+ expect(src).toMatch(/\/api\/v1\/lxmf\/conversations/);
+ expect(src).toMatch(/params:\s*\{\s*limit:\s*2000\s*\}/);
+ });
+
+ it("MessagesPage skips poll when document is hidden and uses a longer interval", () => {
+ const src = readFileSync(resolve(ROOT, "meshchatx/src/frontend/components/messages/MessagesPage.vue"), "utf8");
+ expect(src).toMatch(/visibilityState === "hidden"/);
+ expect(src).toMatch(/15000/);
+ expect(src).not.toMatch(
+ /setInterval\(\(\) => \{\s*this\.getConversations\(\);\s*this\.getFolders\(\);\s*\}, 5000\)/
+ );
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────